Skip to content

feat(api): accept user and team locators on grants - #1192

Merged
IAM-marco merged 21 commits into
mainfrom
cursor/grant-locators-54e2
Sep 21, 2026
Merged

IAM-marco merged 21 commits into
mainfrom
cursor/grant-locators-54e2

Conversation

@adlerhurst

@adlerhurst adlerhurst commented Sep 9, 2026

Copy link
Copy Markdown
Member

Summary

Breaking wire update of the grants HTTP API (alpha). Create, get, and list name the bound person as user (user_id) or team (team_id) instead of principal_type / principal_id. Create accepts an id or a unique locator (user.identifier / team.name) and resolves it in the platform project. Storage (authz_assignments) and authz.granted / authz.revoked events stay (principal_type, principal_id).

POST /grants with user.identifier is accepted with 201 whether or not a user matched, so the HTTP status cannot enumerate whether an address belongs to an active platform user. A miss or several matches still return a Grant and write nothing. A duplicate returns the existing row. Granting the session caller's own resolved user is grant.invalid. user_id and team locators keep 404 / 409.

expand: ["principal"] still exists as one flag for both kinds. Extra fields are copied onto the same user / team object (no sibling principal). Missing principals stay a degraded ref. GET-by-id takes the same expand query param and the same dual user.read + team.read gate.

Console add-admin stays picker-by-id; the request body is the mechanical { user: { user_id }, relation: "admin" } compile fix.

Validation

Console Settings → Admins against moon run console:dev-real (dev@zitadel.local): create a grant for ada.lovelace@example.com (POST /grants with { user: { user_id }, relation: "admin" }), the list shows the row at level Admin, then remove via DELETE /grants/{id} and the table returns to “No admins yet.”

console_admins_create_list_delete_grant.mp4

  • go test ./internal/service -count=1 -run TestGrantService_Create — pass (identifier miss/ambiguous/duplicate → 201; self-grant → grant.invalid; user_id/team unchanged)
  • go test ./internal/api -count=1 -run 'TestCreateGrant|TestGrantCaller|TestGrantResponse' — pass
  • moon run server:generate and moon run server:check-generate — pass
  • Spanner ci / full-pr failed on TestGrantCreateLocators/unknown_identifier_is_accepted_without_a_row racing sibling parallel creates; isolated that subtest onto its own project. Docker is unavailable here so the Spanner lane was not re-run locally.

Release notes / changeset

Changeset: .changeset/grant-api-locators.md@zitadel/server minor. Callers can create grants by user identifier or team name; create/get/list drop principal_type / principal_id. Creating by identifier always returns 201 except self-grant; it does not reveal whether the address matched.

No additional changeset for the console test isolation fix — no shipped behavior changed.

Notes

  • Nested OpenAPI oneOf without a discriminator made ogen skip grant operations, so create/get/list stay a single object and XOR is enforced in the handler/service as grant.invalid.
  • Add-admin-by-email UX is out of scope (later PR).
  • Identifier create opacity is HTTP-status only: a GET of a synthetic asgn_ id still 404s. Volume/timing probes and rate limits stay on Neutral responses for grant creation by identifier #1229.
  • QueryUsers allowlist cleanup and Console people-picker replacement stay on Neutral responses for grant creation by identifier #1229 after Console drops the picker.

To show artifacts inline, enable in settings.

Open in Web Open in Cursor 

Create, get, and list name the bound person as user or team instead of
principal_type / principal_id. Create resolves user_id, identifier,
team_id, or team name in the platform project; storage and events stay
id-based. Expand inlines extras onto the same user / team ref.

Co-authored-by: Silvan <adlerhurst@users.noreply.github.com>
@vercel

vercel Bot commented Sep 9, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
nextgen Ready Ready Preview Sep 21, 2026 11:49am UTC
2 Skipped Deployments
Project Deployment Actions Updated
nextgen-docs Skipped Skipped Sep 21, 2026 11:49am UTC
nextgen-mock-zitadel Skipped Skipped Sep 21, 2026 11:49am UTC

Request Review

@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

🦋 Changeset detected

Latest commit: cc9c09b

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 20 packages
Name Type
@zitadel/server Minor
@zitadel/cli Minor
@zitadel/testing Minor
@zitadel/server-linux-x64 Minor
@zitadel/server-linux-arm64 Minor
@zitadel/server-darwin-x64 Minor
@zitadel/server-darwin-arm64 Minor
@zitadel/server-win32-x64 Minor
@zitadel/api Minor
@zitadel/config Minor
@zitadel/components Minor
@zitadel/sdk-core Minor
@zitadel/sdk-next Minor
@zitadel/sdk-nuxt Minor
@zitadel/sdk-react Minor
@zitadel/sdk-vue Minor
@zitadel/sdk-angular Minor
@zitadel/sdk-solid Minor
@zitadel/sdk-svelte Minor
@zitadel/sdk-qwik Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

QueryGrants filtering for user_id/team_id regresses the documented contains operation by returning NotImplemented instead of performing a substring match.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR delivers a breaking (alpha) update to the Grants HTTP API by replacing (principal_type, principal_id) with explicit user / team locators, adding locator-based create semantics (user identifier / team name), and changing expand: ["principal"] to inline expanded fields onto the same user / team objects. It also updates console admin-grant creation and all relevant tests/docs/specs accordingly.

Changes:

  • Replace principal_type / principal_id with user (user_id / identifier) or team (team_id / name) across create/get/list/query.
  • Implement locator resolution in the Grant service (user by designated identifier; team by case-insensitive name) and add GET-by-id expand=principal support with user.read + team.read gating.
  • Update console admin UI + tests and refresh OpenAPI + generated API artifacts + ADRs + changeset.
File summaries
File Description
internal/service/grant.go Implements locator resolution, new validation, hydration toggled by expand, and updated query filtering fields.
internal/service/grant_test.go Updates existing grant tests and adds service-level locator resolution coverage.
internal/api/grant.go Maps new request shapes to service inputs; enforces expand gating for GET/query; maps response into new user/team shapes.
internal/api/grant_internal_test.go Adds unit tests for create request mapping and updated grant response behavior.
internal/api/integration_test/grant_test.go Updates integration tests for new wire format, expand behavior, and locator creates.
apps/console/src/routes/_authed/settings/admins.tsx Switches admin listing/labeling and “already admin” detection to user/team objects.
apps/console/src/routes/_authed/settings/admins.spec.tsx Updates MSW fixtures and assertions to the new grants wire format and expand semantics.
apps/console/src/components/add-admin-dialog.tsx Updates grant creation payload to { user: { user_id }, relation }.
docs/adrs/README.md Extends ADR 059 index description to mention the grant expansion exception.
docs/adrs/059-expanding-embedded-objects.md Documents the grant-specific expand exception (inline extras on refs, degraded ref behavior).
docs/adrs/054-customer-collaboration-grants.md Updates grant principal naming to include locator-based addressing.
api/openapi/endpoints/grants/user-locator.yaml Adds UserLocator schema (user_id XOR identifier).
api/openapi/endpoints/grants/team-locator.yaml Adds TeamLocator schema (team_id XOR name).
api/openapi/endpoints/grants/query/query-grants-request.yaml Updates expand description to match inline-extras behavior.
api/openapi/endpoints/grants/query/methods.yaml Updates query endpoint docs for inline expand semantics + gating.
api/openapi/endpoints/grants/query/grant-filter-field.yaml Replaces principal filters with user_id / team_id.
api/openapi/endpoints/grants/query/grant-expand.yaml Redefines principal expand as “inline extras onto user/team ref”.
api/openapi/endpoints/grants/methods.yaml Updates create endpoint docs for locator inputs and ref-only 201 response.
api/openapi/endpoints/grants/grant.yaml Replaces grant schema to discriminate on presence of user vs team and removes sibling principal.
api/openapi/endpoints/grants/grant-user.yaml Introduces GrantUser (user-ref + inline expand extras).
api/openapi/endpoints/grants/grant-team.yaml Introduces GrantTeam (team-ref + inline expand extras).
api/openapi/endpoints/grants/grant-expanded-principal.yaml Removes the previous expanded-principal union schema.
api/openapi/endpoints/grants/create-grant-request.yaml Updates CreateGrantRequest to accept user or team locator objects.
api/openapi/endpoints/grants/by_id/methods.yaml Adds GET-by-id expand query param contract.
api/openapi/endpoints/grants/by_id/getGrant-error-response.yaml Adds user.permission_denied / team.permission_denied to GET error catalog.
api/generated/oas_validators_gen.go Regenerates validators for new request/response shapes.
api/generated/oas_unimplemented_gen.go Regenerates unimplemented handler docs for updated grants endpoints.
api/generated/oas_server_gen.go Regenerates handler interface and endpoint docs for updated grants endpoints.
api/generated/oas_schemas_gen.go Regenerates Go types for new locator and grant schemas.
api/generated/oas_parameters_gen.go Adds expand query decoding for GetGrant.
api/generated/oas_json_gen.go Regenerates JSON encode/decode for new types and removed principal union.
api/generated/oas_handlers_gen.go Regenerates server request handlers (expand param plumbing).
api/generated/oas_client_gen.go Regenerates client request encoding (expand param on GetGrant).
.changeset/grant-api-locators.md Adds a minor changeset describing the API wire update and locator create support.
Review details
  • Files reviewed: 26/34 changed files
  • Comments generated: 1
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread internal/service/grant.go
cursoragent and others added 2 commits September 9, 2026 15:28
Co-authored-by: Silvan <adlerhurst@users.noreply.github.com>
Grant expand inlines user attributes onto GrantUser, so ogen types them
as GrantUserAttributes rather than UserAttributes. Compare the JSON in
the expand integration test. Restore contains on user_id / team_id
filters to match the previous principal_id filter.

Co-authored-by: Silvan <adlerhurst@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Identifier lookup is not scoped to the schemas designating each property and can grant access to the wrong user.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details
  • Files reviewed: 26/34 changed files
  • Comments generated: 4
  • Review effort level: Balanced

Comment thread internal/service/grant.go
Comment thread api/openapi/endpoints/grants/grant-user.yaml Outdated
Comment thread api/openapi/endpoints/grants/team-locator.yaml Outdated
Comment thread api/openapi/endpoints/grants/user-locator.yaml Outdated
cursoragent and others added 2 commits September 9, 2026 17:35
Identifier lookup now ANDs schema_url to the schemas that set
x-identifier for that key, so a unique undesignated property on
another schema cannot be selected.

Co-authored-by: Silvan <adlerhurst@users.noreply.github.com>
GrantUser.attributes is user content, not the schema document. Extra
locator properties stay additionalProperties: false (req.invalid);
XOR of id vs locator remains grant.invalid.

Co-authored-by: Silvan <adlerhurst@users.noreply.github.com>
configureZitadel is write-once on globalThis, which survives Vitest
isolate. A file that bound the DEV `/api` default first caused later
specs to fetch localhost:3000 while their MSW handlers waited on
http://localhost/api, so the first test in many files timed out in CI.

Co-authored-by: Silvan <adlerhurst@users.noreply.github.com>
@vercel
vercel Bot temporarily deployed to Preview – nextgen-docs September 9, 2026 22:15 Inactive
@vercel
vercel Bot temporarily deployed to Preview – nextgen-mock-zitadel September 9, 2026 22:15 Inactive
@IAM-marco
IAM-marco self-requested a review September 14, 2026 09:18
…pers

The e2e grantProjectAdmin and dev-real grantDevUserAdmin helpers still sent
the old { principal_type, principal_id } body to POST /grants, which the new
locator contract rejects with grant.invalid ("exactly one of user or team is
required"). Send { user: { user_id }, relation } instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GVV2xsPNwqKzQY1xVZu73
@vercel
vercel Bot temporarily deployed to Preview – nextgen-docs September 21, 2026 10:15 Inactive
@vercel
vercel Bot temporarily deployed to Preview – nextgen-mock-zitadel September 21, 2026 10:15 Inactive
@IAM-marco
IAM-marco enabled auto-merge (squash) September 21, 2026 10:30

@IAM-marco IAM-marco left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. The two-axis review is fully addressed and CI is green.

  • All prior review threads are resolved: the eight simplification points were fixed as suggested, and the one kept item (the handler-side exactly-one check) has a sound rationale (it checks field presence where the service checks trimmed values).
  • The anti-enumeration follow-up landed: identifier create returns a success shape on a miss, ambiguity, or duplicate, and a session caller granting themselves is rejected. The residual body-shape leak is tracked in #1229.
  • CI is green on this head. Getting there fixed two flaky infra issues and one real bug that the flakes had been masking: the Spanner-emulator lane timeout was raised (its slow authz list test runs in ~8m under the new budget), and two real-instance helpers that still sent the old grant body now send the user locator shape.

…or shape

PR #1210 auto-generates `zitadel grants ...` CLI commands from the OpenAPI
spec and shipped tests against the old grant shape. This PR (#1192) changed
the grant contract to a user/team locator, so the two collided and cli:test
failed. Reconcile the CLI to the new contract:

- list filters: drop principal_type/principal_id (the server no longer
  accepts them) and send user_id/team_id instead.
- columns/detail: read the nested response via dotted paths user.user_id and
  team.team_id (a grant is user XOR team).
- create: the locator (user/team) is a nested object, so it goes through
  --data; only the scalar relation/expires_at derive flags. This is the CRUD
  framework's intended behavior, left unchanged.
- tests: update the three failing unit files to the new schema and the
  framework's actual derived flags.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GVV2xsPNwqKzQY1xVZu73
@vercel
vercel Bot temporarily deployed to Preview – nextgen-docs September 21, 2026 11:48 Inactive
@vercel
vercel Bot temporarily deployed to Preview – nextgen-mock-zitadel September 21, 2026 11:48 Inactive
@IAM-marco
IAM-marco merged commit a9dabbc into main Sep 21, 2026
14 checks passed
@IAM-marco
IAM-marco deleted the cursor/grant-locators-54e2 branch September 21, 2026 12:08
@github-project-automation github-project-automation Bot moved this from In review to Done in Engineering Kanban Sep 21, 2026
IAM-marco added a commit that referenced this pull request Sep 22, 2026
## TL;DR

Creating a grant by email address (`POST /grants` with
`user.identifier`) now answers `202 Accepted` with no body, whatever
happened: a match, a duplicate, an unknown address or an ambiguous one
all get the same empty reply. Nothing to compare across repeats, no id
to replay. Creating by `user_id` or team keeps `201` with the grant,
whose `user` / `team` now carry only the id. Reads (`GET`/list) still
return identifier and display. Granting yourself is rejected by user id
as well as by identifier.

## Why

Marco recorded the leak on issue #1229: PR #1192 made the HTTP status
neutral, but the body still hydrated the user on a hit and not on a
miss. The first commit here stripped the body to ids only. Copilot then
showed that ids alone still leak: a repeat returned the same grant id on
a hit and fresh random ids on a miss, and the returned user id could be
replayed through the explicit path, where 409 and 404 tell the two
apart. The second commit removes the body from the identifier path
entirely.

## What changed

- The identifier path returns a nil grant from the service and the
handler answers with the generated empty `202`. The synthetic-grant and
existing-grant helpers are deleted.
- Creating by `user_id` or team never hydrates refs: one id-only builder
produces every `201` body. The old `hydrateCreated` helper is gone.
- Self-grant check also runs on the explicit `user_id` path.
- OpenAPI: new `202` response on create, `201` scoped to the id and team
locators, locator and error descriptions rewritten. Regenerated ogen
files. Changeset for `@zitadel/server` (minor).
- The Console discards the create reply and the CLI prints an empty
result on 202, so neither needed code changes (typecheck and tests were
run for both).

<details>
<summary>Not in this PR: the users-query allowlist cleanup</summary>

The issue's third task, dropping `QueryUsers` from the session allowlist
in `internal/api/security.go`, is left out on purpose. That map does not
grant access. It marks operations that need a logged-in user rather than
any session cookie, so removing the users query from it would let an
anonymous cookie through, the opposite of the goal. The switch that
decides whether a browser session may call the users query at all is the
security block on `POST /users/query` in the OpenAPI, where the session
cookie is listed as an accepted credential.

Flipping that switch is not right either. The add-admin picker is not
the only caller: the Console users page lists users through the same
session call, and the CLI calls it too (with a project secret, so
unaffected). The users query already checks whether the caller may list
users of the session's project. What is missing is a permission that
only admins hold, and that is #1024's job. Until it lands, anyone with
any access to the Console project sees the list, a limitation the issue
already records.

Rate limiting the identifier path against volume and timing probes is
its own follow-up ticket, not part of this change.

</details>

## Tests

Written first, per TDD:

- Integration tests (postgres lane) covering bare create bodies on the
user_id, team, and identifier paths.
- A new key-set test that proves a hit response and a miss response have
identical JSON shape (same top-level keys, same nested user keys).
- A new test asserting your own `user_id` is rejected the same way your
own identifier already was.
- Service unit tests updated to match the id-only builder; the old
hydrate-fallback test deleted since there's no hydrate path left to fall
back from.
- Unit tests, `go vet`, OpenAPI lint, and check-generate all green.

Closes #1229

This branch was successfully deployed

1 active and 2 inactive deployments
Preview – nextgen cc9c09b2 Deployed Sep 21, 2026 by vercel[bot]
Preview – nextgen-mock-zitadel cc9c09b2 Deployed Sep 21, 2026 by vercel[bot]
Preview – nextgen-docs cc9c09b2 Deployed Sep 21, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

4 participants